iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Claude AI

用 AI Agent 重構一套無框架的 legacy PHP 系統系列 第 14

Day 14:型別提示具體類別 vs 介面——DI 容器 autowire 出空殼的真實案例

  • 分享至 

  • xImage
  •  

前言:物件建立成功,代表它是對的嗎?

「這個物件都能正常 new 出來、程式也沒噴任何錯誤,代表這個依賴注入設定是對的吧?」

答案是:不一定。今天要講的這個陷阱,最麻煩的地方就在這裡——它不會在物件建立的當下爆炸,只會在你真正需要它做點什麼的那一刻,才冷不防炸開。而「建立成功、沒有報錯」正是 AI 判斷「這個依賴注入設定沒問題」時最容易誤信的假訊號。

昨天講的是同一個容器單例,因為在建構子快取了不該快取的物件而出包;今天要講的是同一類容器機制的另一個陷阱——這次問題不在「快取了什麼」,而在「型別提示提示得太具體」。

今日目標

  • 理解 DI 容器的 autowire 機制,跟「靠猜測解析型別」之間的界線在哪裡
  • 看一個真實案例:建構子型別提示一個具體類別,容器靜默生出一個空殼物件
  • 理解為什麼這種錯誤特別難抓——沒有立即噴錯,只會延遲爆炸
  • 建立「型別提示要提示到什麼程度」的具體判斷原則

一個看起來完全正常的建構子

假設有一段程式碼,建構子長這樣:

❌ 型別提示具體類別,指望 autowire 解析出「正確配置好的」實例:
class ReportGenerator
{
    public function __construct(private AppContainer $container) {}

    public function generate(): array
    {
        $service = $this->container->get(SomeInterface::class);
        return $service->buildReport();
    }
}

這段程式碼在絕大多數情況下看起來完全正常:ReportGenerator 被建立出來,沒有任何錯誤訊息,型別提示也對,語法檢查全過。問題出在 AppContainer 是一個自訂的具體類別,不是介面——PHP-DI 這類自動裝配容器,遇到型別提示是具體類別、而這個類別又沒有被明確綁定過的情況,會嘗試用 autowire 機制「猜」一個實例出來:直接呼叫這個類別的建構子生一個新物件。

聽起來合理,但實際發生的事情是:容器真的生出了一個 AppContainer 的新實例——只是這個新實例完全沒有跑過應用程式原本的 bootstrap 流程,裡面沒有任何綁定設定。它是一個看起來型別正確、實際上是空殼的容器。

為什麼這是「延遲爆炸」,而不是立刻爆炸

這正是這個陷阱最陰險的地方:new ReportGenerator() 這一步完全不會出錯。容器 autowire 出一個空殼 AppContainer,塞進 ReportGenerator 的建構子,一切順利完成。物件建立起來了,型別對了,沒有任何警告。

真正爆炸的時刻,是後面呼叫 generate()$this->container->get(SomeInterface::class) 執行的那一刻——因為這個空殼容器裡沒有 SomeInterface 的任何綁定設定,會直接丟出「the class is not instantiable」這類錯誤。

如果你請 AI 驗證這段程式碼有沒有問題,AI 很可能只做到「物件能不能建立成功」這一層測試,因為建立過程真的沒有任何異常訊號。**這正是 Day 01 那句話在依賴注入層的具體樣貌:AI 給出「已確認能建立、沒有錯誤」的結論,範圍其實只涵蓋到物件建立那一刻,沒有涵蓋到這個物件真正被使用時的行為。**兩者聽起來很接近,實際上是完全不同層級的驗證。

正確做法:型別提示介面,不提示具體容器類別

✅ 不依賴 autowire 猜測容器實例,改用明確的全域存取點:
class ReportGenerator
{
    public function generate(): array
    {
        $service = app(SomeInterface::class);
        return $service->buildReport();
    }
}

這裡的關鍵改變不是「換一種語法比較潮」,而是不再讓容器對著一個具體類別做自動裝配的猜測。全域的 app() helper(或任何等效的明確存取點)直接拿到的是應用程式啟動時真正 bootstrap 過的那個容器實例,不會有「猜出一個空殼」的問題。

如果專案架構上真的需要把容器當依賴傳進來,正確做法是型別提示對應的介面(例如 ContainerInterface),並且在應用程式啟動時明確把介面綁定到正確配置好的實例——這樣容器解析到的物件,一定是那個綁定過的實例,不會有 autowire 憑空生一個新物件的空間。

型別提示越具體,容器猜測的空間就越大

把這兩個 DI 容器陷阱(昨天的單例快取、今天的 autowire 空殼)放在一起看,會發現一個共通的判斷原則:型別提示提示得越具體,容器能自己「猜」出東西來補上的空間就越大,而猜出來的東西是不是你要的那個,往往要等到真正用到的那一刻才知道。 型別提示一個沒有明確綁定的具體類別,容器唯一能做的事就是憑建構子簽章生一個新物件出來——這個新物件不會是應用程式裡「那個」正確配置好的實例,只會是同名同姓的陌生人。

AI 在檢視依賴注入設定時,容易只確認「型別對不對」,卻不會主動去確認「這個型別背後,容器到底是解析到綁定好的實例,還是自己猜生了一個新的」——這兩件事在語法層面完全看不出差別,只有在執行期才會露餡。

原則語言無關,行為細節是這個容器實作的選擇

這裡今天講的具體行為(PHP-DI 對未綁定具體類別做 autowire 的方式)是這個專案用的容器實作特有的細節,不是所有 DI 容器都一樣,不同容器對這種情況的處理甚至完全相反。Spring 對沒有明確 @Bean 設定的具體類別做元件掃描時,也可能出現類似「猜測空間隨型別提示越具體而放大」的風險模式;但 .NET Core 內建 DI 容器反而走了另一條路——遇到沒註冊過的具體型別時,會立刻丟出明確的例外,直接告訴你「這個型別沒有被註冊」,不會靜默生出空殼物件、更不會延遲到後續呼叫才爆炸。這其實是個值得對照的設計取捨:PHP-DI 選擇靜默 autowire 來降低設定成本,.NET Core 選擇立即報錯來換取更高的可預期性,兩種設計沒有絕對的對錯,但對「型別提示越具體,容器行為就越難預測」這件事,各家容器給出的答案並不一樣。真正該記住的判斷原則是:依賴注入的型別提示越靠近介面,行為就越可預期;越靠近具體類別,就越依賴容器當下的自動裝配規則——而這條規則會不會在你意識到之前就先爆炸,取決於你用的容器選擇了哪一種設計。

今日思考題

回想你手上專案裡的建構子型別提示:有沒有哪個地方型別提示的是一個具體類別,而不是介面?你能確定容器在那個地方一定解析到「正確配置好的那個實例」,而不是自動裝配猜出來的空殼嗎?

今日重點回顧

  • 型別提示具體類別、指望容器 autowire 解析,容器可能靜默生出一個沒跑過 bootstrap 的空殼實例
  • 這類錯誤不會在物件建立時爆炸,只會在真正使用到缺失的綁定時才延遲爆炸——AI 很容易只驗證「建立成功」就下結論
  • 正確做法是型別提示介面、或改用明確的全域存取點,避免容器對具體類別做自動裝配的猜測
  • 「型別提示越具體,容器猜測空間越大」這個風險模式跨語言的 DI 容器都存在,只是行為細節不同

明日預告

明天要離開依賴注入的話題,回到資料本身——數值精度為什麼是金額運算裡不能妥協的一件事,以及為什麼這套系統強制要求用 bcmath,而不是相信原生的浮點數運算。


上一篇
Day 13:依賴注入容器的陷阱——單例快取了不該快取的物件
下一篇
Day 15:數值精度——為什麼金額運算要求強制使用 bcmath
系列文
用 AI Agent 重構一套無框架的 legacy PHP 系統21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言